--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 06e2faeae9854d08d5dc0ad0812c550f4e94558e
Parents : 99b3630
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-26T06:18:21-05:00
feat: improve Linux Landlock support for user-local tools and subprocesses, improve messaging UI for self-notes, and update documentation
Changes
42 files changed, 1681 insertions(+), 94 deletions(-)
Diff
diff --git a/.github/workflows/android-emulator-smoke.yml b/.github/workflows/android-emulator-smoke.yml
index 2ab5601c..2e31782d 100644
--- a/.github/workflows/android-emulator-smoke.yml
+++ b/.github/workflows/android-emulator-smoke.yml
@@ -1,5 +1,5 @@
# Nightly / manual Android emulator smoke: build x86_64 debug APK, install on
-# an AVD, launch MainActivity, and require /api/v1/status == ok.
+# an AVD, launch MainActivity, and require /api/v1/status to respond (ok or starting).
# Catches Chaquopy boot failures (missing modules, storage lock SystemExit, etc.)
# that assembleDebug alone cannot see.
#
diff --git a/AGENTS.md b/AGENTS.md
index f2ae1980..cb33111b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,3 +4,12 @@ Start at [docs/agents/README.md](docs/agents/README.md).
For architecture and invariants, read [docs/agents/overview.md](docs/agents/overview.md).
For mesh design, read [docs/agents/conventions/reticulum-zen.md](docs/agents/conventions/reticulum-zen.md).
+
+## Linux Landlock (quick)
+
+MeshChatX applies an optional filesystem sandbox on Linux (`MESHCHAT_LANDLOCK`, see overview). Besides SQLite `temp_store`, it affects **subprocesses** and **user-local tools** (pipx Argos Translate, `~/.local/bin` wrappers, rnsh/rnx when launched as PATH scripts).
+
+- Implementation: `meshchatx/src/backend/landlock_sandbox.py`
+- SQLite symptoms and pragmas: [docs/agents/skills/landlock-sqlite/SKILL.md](docs/agents/skills/landlock-sqlite/SKILL.md)
+- Integration probes (subprocess spawn, translator Argos, home write denial): `tests/backend/test_landlock_integration_surfaces.py` and `tests/backend/landlock_integration_support.py`
+- After changing Landlock rules or any code that `subprocess`/`Popen`s external binaries, extend read/RW roots and add a probe test in that integration file.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fecb183f..19eab4fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file.
### Fixed
+- **Messages (personal notes)**: Sending to your own LXMF address or identity is stored locally as delivered (method `local`) without LXMF router outbound, so self-chat no longer hangs in a waiting state.
+- **Messages sidebar**: Conversation list updates optimistically when you press Send, before the server acknowledges the message.
+- **E2E**: Playwright API helpers attach CSRF tokens for direct backend POSTs; `pretest:e2e` installs Chromium before the suite runs.
- **Android LXST / Codec2**: When the Chaquopy `pycodec2.so` extension is an empty stub, fall back to a ctypes Codec2 binding over the bundled `libcodec2.so` so LXST Codec2 voice profiles work on device. Still preload jniLibs Codec2 and reload soft-imported LXST bindings after probe.
- **Android RNode flasher**: Open native flasher returns a real status, keeps USB-serial classes through R8, uses an ActionBar theme, and surfaces startup failures instead of silently doing nothing. Bluetooth Open settings tries GrapheneOS-friendly fallbacks (app details, Bluetooth settings, general Settings) instead of toasting unavailable.
- **Connection banners**: Do not flash disconnected on startup before the first successful WebSocket open. Debounce disconnect UI for 2.5s and only show reconnected when the disconnect banner was actually shown. Foreground recovery prefers a ping for longer before forcing a reconnect.
@@ -14,8 +17,8 @@ All notable changes to this project will be documented in this file.
- **HTTP security headers**: Send Permissions-Policy allowing microphone and camera for this origin so reverse proxies that omit the header do not block capture by default.
- **UI language**: Persist language changes over the config HTTP API (not WebSocket-only), normalize legacy locale codes, and stop the Reticulum manual language picker from overwriting app UI language.
- **Network visualizer**: WebGL background follows light theme and clears while the WASM scene is still loading. Boot theme removes stale dark class when light is selected.
-
-## [4.8.1] - 2026-07-25
+- **Translator (Landlock)**: On Linux, allow read/execute for user-local pipx CLIs (`~/.local/bin`, `~/.local/share/pipx`) and read-write for Argos Translate data under `~/.local/share/argos-translate`, so `argospm` language lists and local Argos translation work with the filesystem sandbox enabled.
+- **Tests**: Landlock integration probes for subprocess spawn, translator Argos language listing, user-local CLI execution, and home write denial outside RW roots (`tests/backend/test_landlock_integration_surfaces.py`).
### Fixed
diff --git a/docs/agents/README.md b/docs/agents/README.md
index 50d75583..aa746169 100644
--- a/docs/agents/README.md
+++ b/docs/agents/README.md
@@ -54,12 +54,12 @@ Optional editor rules (if present under `.cursor/rules/` or similar):
### Identity and data
-| Skill | Use when |
-| -------------------------------------------------------------------------- | ------------------------------------------------- |
-| [identity-restore](skills/identity-restore/SKILL.md) | Identity key vs database zip restore |
-| [identity-switch-teardown](skills/identity-switch-teardown/SKILL.md) | Live identity switch without cross-identity leaks |
-| [database-migrations-backups](skills/database-migrations-backups/SKILL.md) | Schema bumps, backups, snapshots, restore safety |
-| [landlock-sqlite](skills/landlock-sqlite/SKILL.md) | Landlock + SQLite conversation failures |
+| Skill | Use when |
+| -------------------------------------------------------------------------- | --------------------------------------------------- |
+| [identity-restore](skills/identity-restore/SKILL.md) | Identity key vs database zip restore |
+| [identity-switch-teardown](skills/identity-switch-teardown/SKILL.md) | Live identity switch without cross-identity leaks |
+| [database-migrations-backups](skills/database-migrations-backups/SKILL.md) | Schema bumps, backups, snapshots, restore safety |
+| [landlock-sqlite](skills/landlock-sqlite/SKILL.md) | Landlock + SQLite, subprocess/user-local CLI probes |
### Security and plugins
diff --git a/docs/agents/conventions/backend.md b/docs/agents/conventions/backend.md
index 337519c3..94914aa1 100644
--- a/docs/agents/conventions/backend.md
+++ b/docs/agents/conventions/backend.md
@@ -11,6 +11,7 @@ Applies when editing `meshchatx/**/*.py`.
- Multipart parsers must not assume field order.
- SQLite worker connections must set `temp_store=MEMORY` in `DatabaseProvider` (Landlock-safe).
- Under Landlock, memory-pressure must not force FILE temp for conversation queries.
+- Subprocess or user-local CLI features must remain usable under Landlock. Extend `landlock_sandbox.py` read/RW roots and add a probe in `tests/backend/test_landlock_integration_surfaces.py` (see `landlock-sqlite` skill).
- Keep conversation list queries slim: truncate content, derive attachment flags in SQL, avoid shipping full `fields` blobs.
- Identity restore validates size and empty payloads. Preserve existing identity metadata on re-import.
- No backticks in code comments. Prefer plain words or quoted identifiers.
diff --git a/docs/agents/conventions/tests.md b/docs/agents/conventions/tests.md
index 3f30b787..09ec72e4 100644
--- a/docs/agents/conventions/tests.md
+++ b/docs/agents/conventions/tests.md
@@ -7,6 +7,9 @@ Applies when editing `tests/**/*.{py,js}`.
- Mock `window.api` for page tests. Assert toasts when outcomes are user-visible.
- Prefer focused files over full suite unless the user asks for broad runs.
- Landlock tests that apply the sandbox must run in a subprocess (one restrict per process).
+- Shared runner: `tests/backend/landlock_integration_support.py` (`run_python_under_landlock`).
+- Subprocess and user-local CLI probes: `tests/backend/test_landlock_integration_surfaces.py`.
+- Unit tests for rules and ABI: `tests/backend/test_landlock_sandbox.py`.
- Long-running / notification soak suites can hang. Prefer timeouts and avoid piping pytest through `tail` in agent shells.
## Oracle style (no soft fuzz)
diff --git a/docs/agents/module-ownership.md b/docs/agents/module-ownership.md
index 952d0d3e..1aecc183 100644
--- a/docs/agents/module-ownership.md
+++ b/docs/agents/module-ownership.md
@@ -7,23 +7,23 @@ Schema contracts live under `tests/backend/` (not production route modules).
## Backend
-| Domain | Manager modules | HTTP route module | WS module | Primary tests | Frontend page |
-| ----------------- | ------------------------------------------------------------- | ------------------------------------------- | ---------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------- |
-| Identity | `identity_context.py`, `identity_manager.py` | `http/routes/identities.py` | `http/ws/dispatch.py` | `test_identity_*`, identity-switch skills | Identities / tutorial |
-| Status / startup | `lifecycle/deferred_network.py`, `rns_startup_recovery.py` | `http/routes/status.py` | (status via REST) | `test_deferred_network_startup.py`, `test_api_json_contracts.py` | App shell banners |
-| Auth / CSRF | `csrf.py`, `app_security_settings.py`, `ip_allowlist.py` | `http/routes/auth.py` | `http/ws/dispatch.py` | `test_csrf*`, `test_access_attempts_*` | Auth gate in `App.vue` |
-| Messages / LXMF | `message_handler.py`, `lxmf_utils.py`, `database/messages.py` | `http/routes/messages.py`, `lxmf.py` | `http/ws/dispatch.py` | LXMF messaging skill tests | `messages/MessagesPage.vue`, `ConversationViewer.vue` |
-| Telephone / LXST | `telephone_manager.py`, `database/telephone.py` | `http/routes/telephone.py`, `contacts.py` | `http/ws/dispatch.py` | telephone contract tests | `call/CallPage.vue` |
-| RRC | `rrc/manager.py`, `rrc/server.py` | `http/routes/rrc.py` | `http/ws/dispatch.py` | RRC skill tests | `relay/RelayChatPage.vue` |
-| Map | `map_manager.py`, `map_overlay_manager.py` | `http/routes/map.py` | marker updates via broadcast | map manager tests | `map/MapPage.vue` |
-| Plugins | `plugin_manager.py`, `plugin_permissions.py` | `http/routes/plugins.py`, `sideband.py` | `http/ws/dispatch.py` | plugin install security tests | `settings/PluginsSettingsSection.vue` |
-| FileSync | `rns_filesync_handler.py` | `http/routes/filesync.py` | (REST-heavy) | filesync security tests | `filesync/` pages |
-| Interfaces | `interface_editor.py`, `interface_config_parser.py` | `http/routes/interfaces.py` | (announce loops on app) | interface stats tests | `interfaces/InterfacesPage.vue` |
-| Database / backup | `database/*`, `recovery/*` | `http/routes/database.py`, `maintenance.py` | (broadcasts on restore) | backup/restore skill tests | About / maintenance settings |
-| Docs | `docs_manager.py` | `http/routes/docs.py` | (none) | docs manager tests | `docs/DocsPage.vue` |
-| Sandbox | `landlock_sandbox.py`, `seccomp_sandbox.py` | `http/routes/status.py` | (none) | landlock/seccomp tests | Web exposure settings |
-| RNS Link API | `rns_link_manager.py` | (WS only) | `http/ws/dispatch.py` | `test_rns_link_*` | plugins / external tools |
-| Config | `config_manager.py` | `http/routes/config.py` | `http/ws/dispatch.py` | settings config services tests | `settings/SettingsPage.vue` |
+| Domain | Manager modules | HTTP route module | WS module | Primary tests | Frontend page |
+| ----------------- | ------------------------------------------------------------- | ------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
+| Identity | `identity_context.py`, `identity_manager.py` | `http/routes/identities.py` | `http/ws/dispatch.py` | `test_identity_*`, identity-switch skills | Identities / tutorial |
+| Status / startup | `lifecycle/deferred_network.py`, `rns_startup_recovery.py` | `http/routes/status.py` | (status via REST) | `test_deferred_network_startup.py`, `test_api_json_contracts.py` | App shell banners |
+| Auth / CSRF | `csrf.py`, `app_security_settings.py`, `ip_allowlist.py` | `http/routes/auth.py` | `http/ws/dispatch.py` | `test_csrf*`, `test_access_attempts_*` | Auth gate in `App.vue` |
+| Messages / LXMF | `message_handler.py`, `lxmf_utils.py`, `database/messages.py` | `http/routes/messages.py`, `lxmf.py` | `http/ws/dispatch.py` | LXMF messaging skill tests | `messages/MessagesPage.vue`, `ConversationViewer.vue` |
+| Telephone / LXST | `telephone_manager.py`, `database/telephone.py` | `http/routes/telephone.py`, `contacts.py` | `http/ws/dispatch.py` | telephone contract tests | `call/CallPage.vue` |
+| RRC | `rrc/manager.py`, `rrc/server.py` | `http/routes/rrc.py` | `http/ws/dispatch.py` | RRC skill tests | `relay/RelayChatPage.vue` |
+| Map | `map_manager.py`, `map_overlay_manager.py` | `http/routes/map.py` | marker updates via broadcast | map manager tests | `map/MapPage.vue` |
+| Plugins | `plugin_manager.py`, `plugin_permissions.py` | `http/routes/plugins.py`, `sideband.py` | `http/ws/dispatch.py` | plugin install security tests | `settings/PluginsSettingsSection.vue` |
+| FileSync | `rns_filesync_handler.py` | `http/routes/filesync.py` | (REST-heavy) | filesync security tests | `filesync/` pages |
+| Interfaces | `interface_editor.py`, `interface_config_parser.py` | `http/routes/interfaces.py` | (announce loops on app) | interface stats tests | `interfaces/InterfacesPage.vue` |
+| Database / backup | `database/*`, `recovery/*` | `http/routes/database.py`, `maintenance.py` | (broadcasts on restore) | backup/restore skill tests | About / maintenance settings |
+| Docs | `docs_manager.py` | `http/routes/docs.py` | (none) | docs manager tests | `docs/DocsPage.vue` |
+| Sandbox | `landlock_sandbox.py`, `seccomp_sandbox.py` | `http/routes/status.py` | (none) | `test_landlock_sandbox.py`, `test_landlock_integration_surfaces.py`, sqlite landlock tests | Web exposure settings |
+| RNS Link API | `rns_link_manager.py` | (WS only) | `http/ws/dispatch.py` | `test_rns_link_*` | plugins / external tools |
+| Config | `config_manager.py` | `http/routes/config.py` | `http/ws/dispatch.py` | settings config services tests | `settings/SettingsPage.vue` |
## HTTP package layout
diff --git a/docs/agents/overview.md b/docs/agents/overview.md
index 134c80fe..d7bd3b41 100644
--- a/docs/agents/overview.md
+++ b/docs/agents/overview.md
@@ -186,7 +186,21 @@ Critical SQLite interaction:
- Memory-pressure mode may shrink cache/mmap. While Landlock is active, keep MEMORY temp.
- Without Landlock, FILE temp plus a storage-local `sqlite-tmp` TMPDIR is acceptable.
-Landlock apply is process-wide and one-shot. Tests that enable it must run in a subprocess.
+Subprocess and user-local tools:
+
+- Landlock is not only SQLite. Features that **exec** binaries or read data outside `/usr`, the Python prefix, and MeshChatX storage can fail with `Permission denied` while the UI still "detects" the tool on PATH.
+- Read/execute roots include typical pipx layouts: `~/.local/bin`, `~/.local/share/pipx`, and Argos package metadata under `~/.local/share/argos-translate` (RW for Stanza temp files during translation).
+- **Translator (Argos)**: language lists use `argospm list` when the CLI is present. Local translation runs `argos-translate` or the `argostranslate` Python module.
+- **rnsh / rnx**: prefer `python -m …` so installs are not blocked when `~/.local/bin` wrappers are not executable under Landlock. rnsh uses a storage-scoped `HOME`, not the real home directory.
+- **Not covered by default**: symlinks in `~/.local/bin` that point outside allowed trees (for example IDE agent shims), tools installed only under `~/.nvm`, and arbitrary `location_cmd` paths in interface config. Use `MESHCHAT_LANDLOCK=0` to debug or widen rules deliberately in `landlock_sandbox.py`.
+
+Landlock apply is process-wide and one-shot. Tests that enable it must run in a subprocess (`tests/backend/landlock_integration_support.py`).
+
+Verification after Landlock or subprocess-facing edits:
+
+```bash
+uv run pytest tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_sqlite_landlock_temp_store.py -q
+```
Also see `docs/en/platform-guides/linux-sandbox.md` for Firejail / Bubblewrap host examples.
diff --git a/docs/agents/skills/landlock-sqlite/SKILL.md b/docs/agents/skills/landlock-sqlite/SKILL.md
index 35cc50ff..101939d1 100644
--- a/docs/agents/skills/landlock-sqlite/SKILL.md
+++ b/docs/agents/skills/landlock-sqlite/SKILL.md
@@ -1,6 +1,6 @@
# Skill: landlock-sqlite
-Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store, slim queries, memory pressure).
+Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store, slim queries, memory pressure). Also covers subprocess and user-local CLI breakage under Linux Landlock.
# MeshChatX FS sandbox + SQLite
@@ -25,6 +25,27 @@ Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store,
- List queries: `substr(content, 1, 240)` and SQL `instr` flags for attachments
- API: map OperationalError / unable-to-open / locked to **503** with retryable message
+## Subprocess and user-local tools (Linux Landlock)
+
+### Symptoms
+
+- Translator shows Argos as available but **no languages** after Refresh, or translation fails with `Permission denied`
+- `argospm list` or `argos-translate` works in a normal shell but not inside MeshChatX
+- PATH tools in `~/.local/bin` fail while `/usr/bin` tools work
+
+### Root causes
+
+1. Landlock read roots did not include pipx or user-local install paths (`~/.local/bin`, `~/.local/share/pipx`)
+2. Argos Stanza needs **write** under `~/.local/share/argos-translate` (not read-only)
+3. `TranslatorHandler` used Python `argostranslate` with zero packages and did not fall back to `argospm list`
+4. Symlink wrappers in `~/.local/bin` that point outside allowed trees (not fixable by widening `~/.local/bin` alone)
+
+### Required behavior
+
+- `_collect_read_roots()` includes user-local CLI roots when present (`landlock_sandbox._collect_user_local_cli_roots`)
+- `_collect_rw_roots()` includes `~/.local/share/argos-translate` when present
+- New external-tool integrations: add roots and a probe in `tests/backend/test_landlock_integration_surfaces.py`
+
## Windows counterpart
- Module: `meshchatx/src/backend/appcontainer_sandbox.py`
@@ -35,7 +56,7 @@ Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store,
## Verification
```bash
-uv run pytest tests/backend/test_sqlite_landlock_temp_store.py tests/backend/test_sqlite_memory_pressure.py tests/backend/test_landlock_sandbox.py tests/backend/test_appcontainer_sandbox.py tests/backend/test_self_check.py -q
+uv run pytest tests/backend/test_sqlite_landlock_temp_store.py tests/backend/test_sqlite_memory_pressure.py tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_appcontainer_sandbox.py tests/backend/test_self_check.py -q
pnpm exec vitest run tests/frontend/i18n.test.js
bash scripts/ci/github-verify-frozen-sandbox.sh build/exe
```
@@ -51,4 +72,6 @@ For live stress, run Landlock in a **subprocess** (sandbox applies once per proc
- `meshchatx/src/backend/appcontainer_sandbox.py`
- `meshchatx/src/backend/appcontainer_launcher.py`
- `meshchatx/src/backend/seccomp_sandbox.py` (syscall denylist after Landlock)
-- `meshchatx/meshchat.py` (conversations/notifications error mapping)
+- `meshchatx/src/backend/translator_handler.py` (Argos CLI and lib language listing)
+- `tests/backend/landlock_integration_support.py`
+- `tests/backend/test_landlock_integration_surfaces.py`
diff --git a/docs/agents/skills/test-loop/SKILL.md b/docs/agents/skills/test-loop/SKILL.md
index 46a8fa2a..bfc782d7 100644
--- a/docs/agents/skills/test-loop/SKILL.md
+++ b/docs/agents/skills/test-loop/SKILL.md
@@ -49,3 +49,7 @@ pnpm exec vitest run tests/frontend/<related>.test.js
## After identity / Landlock edits
Run the matching skill's verification section before claiming done.
+
+```bash
+uv run pytest tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_sqlite_landlock_temp_store.py -q
+```
diff --git a/docs/en/identity-and-security.md b/docs/en/identity-and-security.md
index 4dc85043..30457f75 100644
--- a/docs/en/identity-and-security.md
+++ b/docs/en/identity-and-security.md
@@ -59,7 +59,7 @@ Privacy mode does not disable Reticulum mesh traffic. It limits clearnet fetches
On Linux, MeshChatX can enable two complementary in-process sandboxes when supported:
-- **Landlock** restricts filesystem paths the backend may use
+- **Landlock** restricts filesystem paths the backend may use. User-local pipx tools (for example Argos Translate under `~/.local`) need explicit read and sometimes write roots. See **Linux sandboxing** in Platform guides.
- **Seccomp-BPF** installs a syscall denylist (via libseccomp) that blocks kernel-admin and related calls a mesh client does not need
Both auto-enable when available and fall back to a no-op when the platform, kernel, or libraries cannot support them. Override with:
diff --git a/docs/en/platform-guides/linux-sandbox.md b/docs/en/platform-guides/linux-sandbox.md
index 294c1ec3..d36dbdcb 100644
--- a/docs/en/platform-guides/linux-sandbox.md
+++ b/docs/en/platform-guides/linux-sandbox.md
@@ -11,6 +11,8 @@ MeshChatX also applies optional **in-process** Linux sandboxes when available:
Those layers fall back cleanly when unsupported. Firejail and Bubblewrap remain useful as an outer wrapper.
+**Landlock and user-local tools:** When Landlock is active, MeshChatX whitelists common pipx paths (`~/.local/bin`, `~/.local/share/pipx`) and Argos Translate data under `~/.local/share/argos-translate` so local translation and similar CLIs keep working. Tools installed elsewhere (for example only under `~/.nvm`) or symlink shims that point outside those trees may still fail with permission errors. Disable Landlock temporarily with `MESHCHAT_LANDLOCK=0` while debugging PATH-only failures.
+
**Containers:** If you already run MeshChatX with Docker or Podman, that is a different isolation model, this document is aimed at **host-installed** `meshchatx` (or `meshchat`).
## Prerequisites
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 185384c2..c7052045 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 5edf5da5..f03df67c 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -5207,12 +5207,15 @@ class ReticulumMeshChat:
raise FileNotFoundError(msg)
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain(cert_path, key_path)
- print(f"HTTPS enabled with custom certificate at {cert_path}")
+ print(
+ f"HTTPS enabled with custom certificate at {cert_path}",
+ flush=True,
+ )
else:
generate_ssl_certificate(cert_path, key_path)
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain(cert_path, key_path)
- print(f"HTTPS enabled with certificate at {cert_path}")
+ print(f"HTTPS enabled with certificate at {cert_path}", flush=True)
except Exception as e:
if custom_ssl:
print(f"Failed to load custom SSL certificate: {e}")
@@ -5357,7 +5360,7 @@ class ReticulumMeshChat:
app.on_startup.append(on_startup)
protocol = "https" if use_https else "http"
- print(f"Starting web server on {protocol}://{host}:{port}")
+ print(f"Starting web server on {protocol}://{host}:{port}", flush=True)
# Start memory diagnostics if enabled
if self._memory_diag_enabled:
@@ -8997,6 +9000,29 @@ class ReticulumMeshChat:
router.handle_outbound(lxmf_message)
# upserts the provided lxmf message to the database
+ def _is_self_lxmf_destination(self, destination_hash: str, context=None) -> bool:
+ """True when destination_hash refers to this identity's own LXMF peer."""
+ ctx = context or self.current_context
+ if not ctx or not ctx.local_lxmf_destination:
+ return False
+ norm_dest = normalize_hex_identifier(destination_hash)
+ if not norm_dest:
+ return False
+ local_lxmf = normalize_hex_identifier(ctx.local_lxmf_destination.hexhash)
+ if norm_dest == local_lxmf:
+ return True
+ if ctx.identity:
+ local_id = normalize_hex_identifier(ctx.identity.hash.hex())
+ if norm_dest == local_id:
+ return True
+ try:
+ resolved = self.get_lxmf_destination_hash_for_identity_hash(norm_dest)
+ if resolved and normalize_hex_identifier(resolved) == local_lxmf:
+ return True
+ except Exception:
+ pass
+ return False
+
def db_upsert_lxmf_message(
self,
lxmf_message: LXMF.LXMessage,
@@ -9005,6 +9031,8 @@ class ReticulumMeshChat:
context=None,
path_finding_measure: str | None = None,
path_row_hash_hex: str | None = None,
+ state_override: str | None = None,
+ method_override: str | None = None,
):
ctx = context or self.current_context
if not ctx:
@@ -9016,6 +9044,10 @@ class ReticulumMeshChat:
reticulum=self.reticulum,
message_router=ctx.message_router,
)
+ if state_override is not None:
+ lxmf_message_dict["state"] = state_override
+ if method_override is not None:
+ lxmf_message_dict["method"] = method_override
lxmf_message_dict["is_spam"] = 1 if is_spam else 0
lxmf_message_dict["attachments_stripped"] = 1 if attachments_stripped else 0
if path_finding_measure is not None:
@@ -9106,10 +9138,20 @@ class ReticulumMeshChat:
raise ValueError(msg) from exc
wants_propagated = delivery_method == "propagated"
+ is_local_self = self._is_self_lxmf_destination(destination_hash, ctx)
+ if is_local_self and wants_propagated:
+ msg = "Propagated delivery is not available for messages to yourself."
+ raise ValueError(msg)
# Direct/opportunistic need a live peer path. Propagated uses the
# propagation node, so skip the peer path wait (still record outcome).
- if wants_propagated:
+ if is_local_self:
+ path_outcome = reticulum_pathfinding.OutboundPathOutcome(
+ True,
+ "local_self",
+ False,
+ )
+ elif wants_propagated:
path_outcome = reticulum_pathfinding.OutboundPathOutcome(
False,
"skipped_for_propagated",
@@ -9121,6 +9163,8 @@ class ReticulumMeshChat:
path_outcome = await self._await_transport_path(destination_hash_bytes)
destination_identity = self.recall_identity(destination_hash)
+ if destination_identity is None and is_local_self and ctx.identity:
+ destination_identity = ctx.identity
if destination_identity is None:
msg = (
"Could not recall destination identity. "
@@ -9130,7 +9174,11 @@ class ReticulumMeshChat:
# Direct/opportunistic delivery needs a live transport path. Propagated
# delivery can proceed without a peer path (it uses the propagation node).
- if not wants_propagated and not path_outcome.path_available:
+ if (
+ not is_local_self
+ and not wants_propagated
+ and not path_outcome.path_available
+ ):
msg = (
"No path to destination. "
"Use Path Finder or wait for a route, then try again."
@@ -9288,6 +9336,40 @@ class ReticulumMeshChat:
current_icon_hash,
)
+ if is_local_self:
+ lxmf_message.state = LXMF.LXMessage.DELIVERED
+ lxmf_message.progress = 1.0
+ local_peer = ctx.local_lxmf_destination.hexhash
+ if not no_display:
+ self.db_upsert_lxmf_message(
+ lxmf_message,
+ context=ctx,
+ path_finding_measure=reticulum_pathfinding.format_outbound_path_finding_measure(
+ path_outcome,
+ ),
+ path_row_hash_hex=local_peer,
+ state_override="delivered",
+ method_override="local",
+ )
+ ws_payload = convert_lxmf_message_to_dict(
+ lxmf_message,
+ include_attachments=False,
+ reticulum=self.reticulum,
+ message_router=ctx.message_router,
+ )
+ ws_payload["state"] = "delivered"
+ ws_payload["method"] = "local"
+ ws_payload["progress"] = 100.0
+ await self.websocket_broadcast(
+ json.dumps(
+ {
+ "type": "lxmf_message_created",
+ "lxmf_message": ws_payload,
+ },
+ ),
+ )
+ return lxmf_message
+
# register delivery callbacks
lxmf_message.register_delivery_callback(
lambda msg: self.on_lxmf_sending_state_updated(msg, context=ctx),
diff --git a/meshchatx/src/backend/landlock_sandbox.py b/meshchatx/src/backend/landlock_sandbox.py
index 7a2958ff..0a188df5 100644
--- a/meshchatx/src/backend/landlock_sandbox.py
+++ b/meshchatx/src/backend/landlock_sandbox.py
@@ -322,6 +322,24 @@ def _existing_dir(path: str | None) -> str | None:
return None
+def _collect_user_local_cli_roots() -> list[str]:
+ """User-installed CLIs (pipx Argos Translate, rnsh/rnx wrappers, git-remote-rns, etc.)."""
+ home = os.path.expanduser("~")
+ if not home or home == "~":
+ return []
+ candidates = (
+ os.path.join(home, ".local", "bin"),
+ os.path.join(home, ".local", "share", "argos-translate"),
+ os.path.join(home, ".local", "share", "pipx"),
+ )
+ paths: list[str] = []
+ for candidate in candidates:
+ existing = _existing_dir(candidate)
+ if existing and existing not in paths:
+ paths.append(existing)
+ return paths
+
+
def _collect_read_roots() -> list[str]:
roots = {
"/usr",
@@ -369,6 +387,8 @@ def _collect_read_roots() -> list[str]:
prefix = _existing_dir(prefix_candidate)
if prefix:
roots.add(prefix)
+ for root in _collect_user_local_cli_roots():
+ roots.add(root)
return sorted(roots)
@@ -391,6 +411,11 @@ def _collect_rw_roots(
paths.append(existing)
if os.path.isdir("/dev"):
paths.append("/dev")
+ argos_share = _existing_dir(
+ os.path.join(os.path.expanduser("~"), ".local", "share", "argos-translate"),
+ )
+ if argos_share and argos_share not in paths:
+ paths.append(argos_share)
return paths
diff --git a/meshchatx/src/backend/translator_handler.py b/meshchatx/src/backend/translator_handler.py
index d883024d..184d3815 100644
--- a/meshchatx/src/backend/translator_handler.py
+++ b/meshchatx/src/backend/translator_handler.py
@@ -223,12 +223,14 @@ class TranslatorHandler:
)
except Exception as e:
print(f"Failed to fetch Argos languages: {e}")
- elif self.has_argos_cli:
- try:
- cli_langs = self._get_argos_languages_cli()
- languages.extend(cli_langs)
- except Exception as e:
- print(f"Failed to fetch Argos languages via CLI: {e}")
+ if self.has_argos_cli:
+ has_argos_langs = any(lang.get("source") == "argos" for lang in languages)
+ if not has_argos_langs:
+ try:
+ cli_langs = self._get_argos_languages_cli()
+ languages.extend(cli_langs)
+ except Exception as e:
+ print(f"Failed to fetch Argos languages via CLI: {e}")
return {
"languages": languages,
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index fdd13b1b..5b3c8358 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1789,7 +1789,13 @@ export default {
required: true,
},
},
- emits: ["close", "reload-conversations", "update:selectedPeer", "update-peer-tracking"],
+ emits: [
+ "close",
+ "reload-conversations",
+ "update:selectedPeer",
+ "update-peer-tracking",
+ "outbound-compose-enqueued",
+ ],
data() {
return {
GlobalState,
@@ -5619,6 +5625,15 @@ export default {
);
await this._sendMessageChain;
},
+ isSelfLxmfDestination(peerHash) {
+ if (!peerHash || typeof peerHash !== "string") {
+ return false;
+ }
+ const mine = (this.myLxmfAddressHash || "").toLowerCase();
+ const identity = (this.config?.identity_hash || "").toLowerCase();
+ const peer = peerHash.toLowerCase();
+ return (mine && peer === mine) || (identity && peer === identity);
+ },
async _enqueueOutboundFromCompose() {
try {
const job = await this.buildOutboundJobSnapshot();
@@ -5626,6 +5641,16 @@ export default {
return;
}
this._outboundQueue.enqueue(job);
+ if (this.isSelfLxmfDestination(job.destinationHash)) {
+ ToastUtils.info(this.$t("messages.self_notes_toast"));
+ }
+ this.$emit("outbound-compose-enqueued", {
+ peerHash: job.destinationHash,
+ previewText: job.text,
+ title: "",
+ fields: job.fields || {},
+ pendingHash: job.pendingHash,
+ });
this.clearComposeAfterEnqueue();
this.$nextTick(() => {
this.adjustTextareaHeight();
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 9b4b915e..3be9f035 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -97,6 +97,7 @@
@update-peer-tracking="onUpdatePeerTracking"
@close="onPaneClose(pane.id)"
@reload-conversations="requestConversationsRefresh"
+ @outbound-compose-enqueued="onOutboundComposeEnqueued"
/>
<div
v-if="!pane.peer"
@@ -982,6 +983,54 @@ export default {
const peer = this.peers[peerHash];
return peer?.custom_display_name ?? peer?.display_name ?? "Anonymous Peer";
},
+ onOutboundComposeEnqueued(payload) {
+ if (!payload || !payload.peerHash) {
+ return;
+ }
+ const peerHash = payload.peerHash;
+ const nowSec = Math.floor(Date.now() / 1000);
+ const stub = {
+ content: payload.previewText || "",
+ title: payload.title || "",
+ fields: payload.fields || {},
+ hash: payload.pendingHash || "",
+ timestamp: nowSec,
+ is_incoming: false,
+ };
+ const peerDisplay = this.peerDisplayNameForConversationSidebar(peerHash);
+ const preview = lxmfConversationListPreview(stub, {
+ myLxmfAddressHash: this.config?.lxmf_address_hash || "",
+ peerDisplayName: peerDisplay,
+ t: this.$t.bind(this),
+ });
+ const idx = this.conversations.findIndex((c) => c.destination_hash === peerHash);
+ if (idx !== -1) {
+ const conv = this.conversations[idx];
+ conv.latest_message_preview = preview;
+ conv.latest_message_title = stub.title;
+ conv.latest_message_created_at = nowSec;
+ conv.updated_at = new Date(nowSec * 1000).toISOString();
+ } else {
+ const peer = this.peers[peerHash];
+ this.conversations.unshift({
+ destination_hash: peerHash,
+ display_name: peer?.display_name ?? this.selectedPeer?.display_name ?? "Anonymous Peer",
+ custom_display_name: peer?.custom_display_name ?? this.selectedPeer?.custom_display_name ?? null,
+ contact_image: peer?.contact_image ?? null,
+ lxmf_user_icon: peer?.lxmf_user_icon ?? null,
+ is_unread: false,
+ is_tracking: peer?.is_tracking ?? false,
+ failed_messages_count: 0,
+ has_attachments: false,
+ latest_message_preview: preview,
+ latest_message_title: stub.title,
+ latest_message_created_at: nowSec,
+ updated_at: new Date(nowSec * 1000).toISOString(),
+ is_contact: false,
+ });
+ this.resolvePeerDisplayName(peerHash);
+ }
+ },
onOutboundMessageCreated(msg) {
const peerHash = this.peerHashFromMessage(msg);
const peerDisplay = this.peerDisplayNameForConversationSidebar(peerHash);
diff --git a/meshchatx/src/frontend/js/outboundMessageStatus.js b/meshchatx/src/frontend/js/outboundMessageStatus.js
index 7f6747c0..964745d5 100644
--- a/meshchatx/src/frontend/js/outboundMessageStatus.js
+++ b/meshchatx/src/frontend/js/outboundMessageStatus.js
@@ -22,7 +22,7 @@ export function outboundBubbleStatusIconName(lxmfMessage) {
if (method === "propagated") {
return "email-check-outline";
}
- if (method === "paper") {
+ if (method === "paper" || method === "local") {
return "note-check-outline";
}
return "check-all";
@@ -31,7 +31,7 @@ export function outboundBubbleStatusIconName(lxmfMessage) {
if (method === "propagated") {
return "email-outline";
}
- if (method === "paper") {
+ if (method === "paper" || method === "local") {
return "note-outline";
}
return "check";
@@ -51,8 +51,14 @@ export function outboundBubbleStatusTitleKey(lxmfMessage) {
if (lxmfMessage.method === "propagated") {
return "messages.outbound_delivered_propagated";
}
+ if (lxmfMessage.method === "local") {
+ return "messages.outbound_delivered_local";
+ }
return "messages.outbound_delivered";
}
+ if (lxmfMessage.method === "local") {
+ return "messages.outbound_saved_local";
+ }
if (lxmfMessage.method === "propagated") {
return "messages.outbound_on_propagation_node";
}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index e79174af..36baafd6 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1734,6 +1734,9 @@
"outbound_sent_network": "Gesendet",
"outbound_delivered": "Zugestellt",
"outbound_delivered_propagated": "Über Propagation zugestellt",
+ "outbound_delivered_local": "Auf diesem Gerät gespeichert",
+ "outbound_saved_local": "Persönliche Notiz auf diesem Gerät",
+ "self_notes_toast": "Persönliche Notizen bleiben auf diesem Gerät und werden nicht über das Mesh gesendet.",
"more_actions": "Weitere Aktionen",
"retry_failed": "Fehlgeschlagene erneut senden",
"telemetry_history": "Telemetrie-Verlauf",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 8ebc5e48..a86a9081 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1702,6 +1702,9 @@
"outbound_sent_network": "Sent",
"outbound_delivered": "Delivered",
"outbound_delivered_propagated": "Delivered via propagation",
+ "outbound_delivered_local": "Saved on this device",
+ "outbound_saved_local": "Personal note on this device",
+ "self_notes_toast": "Personal notes stay on this device. They are not sent over the mesh.",
"no_messages_in_conversation": "No messages in this conversation yet.",
"say_hello": "Say hello!",
"no_active_chat": "No Active Chat",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 4a315be9..b413ca7b 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1702,6 +1702,9 @@
"outbound_sent_network": "Enviado",
"outbound_delivered": "Entregado",
"outbound_delivered_propagated": "Entregado vía propagación",
+ "outbound_delivered_local": "Guardado en este dispositivo",
+ "outbound_saved_local": "Nota personal en este dispositivo",
+ "self_notes_toast": "Las notas personales permanecen en este dispositivo y no se envían por la mesh.",
"no_messages_in_conversation": "Aún no hay mensajes en esta conversación.",
"say_hello": "¡Saluda!",
"no_active_chat": "Sin chat activo",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index a99b2aaf..f30d3047 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1702,6 +1702,9 @@
"outbound_sent_network": "Lähetetty",
"outbound_delivered": "Toimitettu",
"outbound_delivered_propagated": "Toimitettu levityksen kautta",
+ "outbound_delivered_local": "Tallennettu tälle laitteelle",
+ "outbound_saved_local": "Henkilökohtainen muistiinpano tällä laitteella",
+ "self_notes_toast": "Henkilökohtaiset muistiinpanot pysyvät tällä laitteella eivätkä lähde mesh-verkkoon.",
"no_messages_in_conversation": "Tässä keskustelussa ei ole viestejä.",
"say_hello": "Sano hei!",
"no_active_chat": "Ei valittua keskustelua",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 0e2a67bc..b80921d5 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1702,6 +1702,9 @@
"outbound_sent_network": "Envoyé",
"outbound_delivered": "Distribué",
"outbound_delivered_propagated": "Distribué via propagation",
+ "outbound_delivered_local": "Enregistré sur cet appareil",
+ "outbound_saved_local": "Note personnelle sur cet appareil",
+ "self_notes_toast": "Les notes personnelles restent sur cet appareil et ne sont pas envoyées sur le mesh.",
"no_messages_in_conversation": "Pas encore de messages dans cette conversation.",
"say_hello": "Dis bonjour !",
"no_active_chat": "Pas de chat actif",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 67384a78..6bd12072 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1734,6 +1734,9 @@
"outbound_sent_network": "Inviato",
"outbound_delivered": "Consegnato",
"outbound_delivered_propagated": "Consegnato via propagazione",
+ "outbound_delivered_local": "Salvato su questo dispositivo",
+ "outbound_saved_local": "Nota personale su questo dispositivo",
+ "self_notes_toast": "Le note personali restano su questo dispositivo e non vengono inviate sulla mesh.",
"more_actions": "Altre azioni",
"retry_failed": "Riprova messaggi non inviati",
"telemetry_history": "Cronologia telemetria",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index fcf9e29d..891c1656 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1702,6 +1702,9 @@
"outbound_sent_network": "Verzonden",
"outbound_delivered": "Afgeleverd",
"outbound_delivered_propagated": "Afgeleverd via propagatie",
+ "outbound_delivered_local": "Opgeslagen op dit apparaat",
+ "outbound_saved_local": "Persoonlijke notitie op dit apparaat",
+ "self_notes_toast": "Persoonlijke notities blijven op dit apparaat en gaan niet over het mesh.",
"no_messages_in_conversation": "Nog geen berichten in dit gesprek.",
"say_hello": "Zeg hallo!",
"no_active_chat": "Geen actief gesprek",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index bd7b74e1..473075ab 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1734,6 +1734,9 @@
"outbound_sent_network": "Отправлено",
"outbound_delivered": "Доставлено",
"outbound_delivered_propagated": "Доставлено через распространение",
+ "outbound_delivered_local": "Сохранено на этом устройстве",
+ "outbound_saved_local": "Личная заметка на этом устройстве",
+ "self_notes_toast": "Личные заметки остаются на этом устройстве и не отправляются в сеть.",
"more_actions": "Другие действия",
"retry_failed": "Повторить неудачные",
"telemetry_history": "История телеметрии",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index c28f5397..c8d92166 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1702,6 +1702,9 @@
"outbound_sent_network": "已发送",
"outbound_delivered": "已送达",
"outbound_delivered_propagated": "已通过传播送达",
+ "outbound_delivered_local": "已保存在本设备",
+ "outbound_saved_local": "本设备个人备忘",
+ "self_notes_toast": "个人备忘仅保存在本设备,不会通过 mesh 发送。",
"no_messages_in_conversation": "对话中尚无消息",
"say_hello": "打个招呼!",
"no_active_chat": "无活动聊天",
diff --git a/package.json b/package.json
index 74086c41..cdb52977 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"test:i18n": "vitest run tests/frontend/i18n.test.js",
"test:loadtime": "vitest run tests/frontend/LoadTimePerformance.test.js",
"test:ui": "vitest --ui --open --exclude tests/frontend/LoadTimePerformance.test.js",
+ "pretest:e2e": "playwright install chromium",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install chromium",
diff --git a/scripts/ci/android-emulator-smoke.sh b/scripts/ci/android-emulator-smoke.sh
index 9ed4bffb..ecd3122c 100755
--- a/scripts/ci/android-emulator-smoke.sh
+++ b/scripts/ci/android-emulator-smoke.sh
@@ -10,6 +10,10 @@ ACTIVITY="${MESHCHATX_ANDROID_ACTIVITY:-com.meshchatx/.MainActivity}"
STATUS_PATH="${MESHCHATX_SMOKE_STATUS_PATH:-/api/v1/status}"
TIMEOUT_SEC="${MESHCHATX_SMOKE_TIMEOUT_SEC:-180}"
LOGCAT_TAG_FILTER="${MESHCHATX_SMOKE_LOGCAT_FILTER:-Python|meshchat|MeshChat|chaquo|AndroidRuntime}"
+DEVICE_HTTP_PORT="${MESHCHATX_SMOKE_DEVICE_PORT:-8000}"
+FORWARD_LOCAL_PORT="${MESHCHATX_SMOKE_FORWARD_PORT:-18080}"
+PROBE_CURL_MAX_TIME="${MESHCHATX_SMOKE_PROBE_MAX_TIME:-5}"
+last_probe_body=""
APK="${1:-${MESHCHATX_APK:-}}"
if [[ -z "${APK}" ]]; then
@@ -53,11 +57,25 @@ cleanup_logcat() {
kill "${logcat_pid}" >/dev/null 2>&1 || true
wait "${logcat_pid}" >/dev/null 2>&1 || true
}
-trap 'cleanup_logcat; rm -rf "${workdir}"' EXIT
+
+teardown_adb_forward() {
+ adb forward --remove "tcp:${FORWARD_LOCAL_PORT}" >/dev/null 2>&1 || true
+}
+
+setup_adb_forward() {
+ teardown_adb_forward
+ adb forward "tcp:${FORWARD_LOCAL_PORT}" "tcp:${DEVICE_HTTP_PORT}"
+}
+
+trap 'teardown_adb_forward; cleanup_logcat; rm -rf "${workdir}"' EXIT
fail_with_logs() {
local reason="$1"
echo "::error::Android emulator smoke failed: ${reason}"
+ if [[ -n "${last_probe_body}" ]]; then
+ echo "---- last /api/v1/status probe body ----"
+ printf '%s\n' "${last_probe_body}"
+ fi
echo "---- logcat (filtered) ----"
grep -E "${LOGCAT_TAG_FILTER}|PyException|SystemExit|ModuleNotFoundError|StorageLock|backend failed|Error starting MeshChatX" \
"${logcat_file}" | tail -n 200 || true
@@ -66,22 +84,67 @@ fail_with_logs() {
exit 1
}
-probe_status_ok() {
- # Probe from inside the emulator (server binds 127.0.0.1 on-device).
- # Prefer toybox wget (API 30+ images). Fall back to python if present.
+status_body_is_healthy() {
+ local body="$1"
+ if [[ -z "${body}" ]]; then
+ return 1
+ fi
+ if printf '%s' "${body}" | grep -qE '"status"[[:space:]]*:[[:space:]]*"failed"'; then
+ return 1
+ fi
+ # ok: network ready. starting: HTTP up while RNS finishes (normal on Android boot).
+ printf '%s' "${body}" | grep -qE '"status"[[:space:]]*:[[:space:]]*"(ok|starting)"'
+}
+
+probe_status_via_host_forward() {
local body=""
- if adb shell "command -v wget >/dev/null 2>&1" >/dev/null 2>&1; then
- body="$(adb shell "wget -qO- --no-check-certificate https://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
+ if ! command -v curl >/dev/null 2>&1; then
+ return 1
+ fi
+ body="$(
+ curl -ksS --max-time "${PROBE_CURL_MAX_TIME}" \
+ "https://127.0.0.1:${FORWARD_LOCAL_PORT}${STATUS_PATH}" 2>/dev/null || true
+ )"
+ if [[ -z "${body}" ]]; then
+ body="$(
+ curl -fsS --max-time "${PROBE_CURL_MAX_TIME}" \
+ "http://127.0.0.1:${FORWARD_LOCAL_PORT}${STATUS_PATH}" 2>/dev/null || true
+ )"
+ fi
+ if [[ -z "${body}" ]]; then
+ return 1
+ fi
+ last_probe_body="${body}"
+ status_body_is_healthy "${body}"
+}
+
+probe_status_via_device_shell() {
+ # Fallback when host curl or adb forward is unavailable.
+ local body=""
+ local device_base="127.0.0.1:${DEVICE_HTTP_PORT}"
+ if adb shell "command -v curl >/dev/null 2>&1" >/dev/null 2>&1; then
+ body="$(
+ adb shell "curl -ksS --max-time ${PROBE_CURL_MAX_TIME} https://${device_base}${STATUS_PATH}" \
+ 2>/dev/null | tr -d '\r' || true
+ )"
if [[ -z "${body}" ]]; then
- body="$(adb shell "wget -qO- http://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
+ body="$(
+ adb shell "curl -fsS --max-time ${PROBE_CURL_MAX_TIME} http://${device_base}${STATUS_PATH}" \
+ 2>/dev/null | tr -d '\r' || true
+ )"
fi
- elif adb shell "command -v curl >/dev/null 2>&1" >/dev/null 2>&1; then
- body="$(adb shell "curl -ksS --max-time 3 https://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
+ elif adb shell "command -v wget >/dev/null 2>&1" >/dev/null 2>&1; then
+ body="$(
+ adb shell "wget -qO- -T ${PROBE_CURL_MAX_TIME} --no-check-certificate https://${device_base}${STATUS_PATH}" \
+ 2>/dev/null | tr -d '\r' || true
+ )"
if [[ -z "${body}" ]]; then
- body="$(adb shell "curl -fsS --max-time 3 http://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
+ body="$(
+ adb shell "wget -qO- -T ${PROBE_CURL_MAX_TIME} http://${device_base}${STATUS_PATH}" \
+ 2>/dev/null | tr -d '\r' || true
+ )"
fi
else
- # Escape path for embedding in a double-quoted adb shell command.
local py_path
py_path="$(printf '%s' "${STATUS_PATH}" | sed 's/\\/\\\\/g; s/"/\\"/g')"
body="$(
@@ -89,12 +152,12 @@ probe_status_ok() {
import ssl, urllib.request
ctx = ssl._create_unverified_context()
urls = (
- ('https://127.0.0.1:8000${py_path}', ctx),
- ('http://127.0.0.1:8000${py_path}', None),
+ ('https://${device_base}${py_path}', ctx),
+ ('http://${device_base}${py_path}', None),
)
for url, context in urls:
try:
- kwargs = {'timeout': 3}
+ kwargs = {'timeout': ${PROBE_CURL_MAX_TIME}}
if context is not None:
kwargs['context'] = context
print(urllib.request.urlopen(url, **kwargs).read().decode())
@@ -104,9 +167,22 @@ for url, context in urls:
\"" 2>/dev/null | tr -d '\r' || true
)"
fi
- printf '%s' "${body}" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"'
+ if [[ -z "${body}" ]]; then
+ return 1
+ fi
+ last_probe_body="${body}"
+ status_body_is_healthy "${body}"
}
+probe_status_ok() {
+ if probe_status_via_host_forward; then
+ return 0
+ fi
+ probe_status_via_device_shell
+}
+
+setup_adb_forward
+
echo "Waiting up to ${TIMEOUT_SEC}s for backend health (${STATUS_PATH})"
deadline=$((SECONDS + TIMEOUT_SEC))
healthy=0
@@ -125,8 +201,8 @@ while (( SECONDS < deadline )); do
done
if [[ "${healthy}" -ne 1 ]]; then
- fail_with_logs "backend /api/v1/status did not become ok within ${TIMEOUT_SEC}s"
+ fail_with_logs "backend ${STATUS_PATH} did not return ok or starting within ${TIMEOUT_SEC}s"
fi
-echo "Android emulator smoke OK: ${STATUS_PATH} returned status=ok"
+echo "Android emulator smoke OK: ${STATUS_PATH} -> ${last_probe_body}"
grep -E "${LOGCAT_TAG_FILTER}" "${logcat_file}" | tail -n 40 || true
diff --git a/tests/backend/landlock_integration_support.py b/tests/backend/landlock_integration_support.py
new file mode 100644
index 00000000..3763d528
--- /dev/null
+++ b/tests/backend/landlock_integration_support.py
@@ -0,0 +1,85 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Helpers for Landlock integration probes (always run in a subprocess)."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from meshchatx.src.backend.landlock_sandbox import landlock_kernel_supported
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+APPLY_FAILED_MARKER = "APPLY_FAILED"
+
+
+def landlock_integration_available() -> bool:
+ return sys.platform == "linux" and landlock_kernel_supported()
+
+
+requires_landlock_integration = pytest.mark.skipif(
+ not landlock_integration_available(),
+ reason="Landlock apply requires a supported Linux kernel",
+)
+
+
+def run_python_under_landlock(
+ body: str,
+ *,
+ storage: str | Path,
+ reticulum_config_dir: str | Path | None = None,
+ extra_env: dict[str, str] | None = None,
+ timeout: float = 90,
+) -> subprocess.CompletedProcess[str]:
+ """Execute body in a fresh Python process after apply_landlock_sandbox."""
+ storage_s = str(storage)
+ rns_s = str(reticulum_config_dir) if reticulum_config_dir is not None else None
+ rns_kw = f", reticulum_config_dir={rns_s!r}" if rns_s is not None else ""
+ body_clean = textwrap.dedent(body).strip()
+ script = (
+ "import os\n"
+ "import sys\n"
+ 'os.environ["MESHCHAT_LANDLOCK"] = "1"\n'
+ "from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox\n"
+ f"storage = {storage_s!r}\n"
+ "ok = apply_landlock_sandbox(\n"
+ f" storage_dir=storage,\n"
+ f" log_dir=storage{rns_kw},\n"
+ ")\n"
+ "if not ok:\n"
+ f" print({APPLY_FAILED_MARKER!r})\n"
+ " sys.exit(2)\n"
+ f"{body_clean}\n"
+ )
+ env = {**os.environ, **(extra_env or {})}
+ return subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=str(REPO_ROOT),
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ env=env,
+ stdin=subprocess.DEVNULL,
+ )
+
+
+def skip_if_landlock_not_applied(result: subprocess.CompletedProcess[str]) -> None:
+ if APPLY_FAILED_MARKER in (result.stdout or ""):
+ pytest.skip("Landlock could not be applied in this environment")
+
+
+def assert_probe_ok(
+ result: subprocess.CompletedProcess[str],
+ *,
+ ok_marker: str = "OK",
+) -> None:
+ skip_if_landlock_not_applied(result)
+ assert result.returncode == 0, (result.stdout, result.stderr)
+ assert ok_marker in (result.stdout or "")
diff --git a/tests/backend/test_landlock_integration_surfaces.py b/tests/backend/test_landlock_integration_surfaces.py
new file mode 100644
index 00000000..fb12c299
--- /dev/null
+++ b/tests/backend/test_landlock_integration_surfaces.py
@@ -0,0 +1,419 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Landlock regression probes for subprocess and user-local tool surfaces."""
+
+from __future__ import annotations
+
+import os
+import shutil
+import tempfile
+
+import pytest
+
+from meshchatx.src.backend import landlock_sandbox as ll
+from tests.backend.landlock_integration_support import (
+ assert_probe_ok,
+ requires_landlock_integration,
+ run_python_under_landlock,
+ skip_if_landlock_not_applied,
+)
+
+
+def test_collect_user_local_cli_roots_expected_layout(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ local_bin = home / ".local" / "bin"
+ pipx = home / ".local" / "share" / "pipx"
+ argos = home / ".local" / "share" / "argos-translate"
+ local_bin.mkdir(parents=True)
+ pipx.mkdir(parents=True)
+ argos.mkdir(parents=True)
+ monkeypatch.setenv("HOME", str(home))
+
+ roots = set(ll._collect_user_local_cli_roots())
+ assert str(local_bin) in roots
+ assert str(pipx) in roots
+ assert str(argos) in roots
+
+
+def test_collect_rw_roots_includes_argos_share_when_present(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ argos = home / ".local" / "share" / "argos-translate"
+ argos.mkdir(parents=True)
+ monkeypatch.setenv("HOME", str(home))
+
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ rw = ll._collect_rw_roots(str(storage), None, str(storage))
+ assert str(argos) in rw
+
+
+@requires_landlock_integration
+def test_landlock_denies_write_directly_under_home(tmp_path):
+ """HOME is not an RW root; only storage, temp, and explicit shares are."""
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ result = run_python_under_landlock(
+ """
+ import os
+ import sys
+
+ path = os.path.join(
+ os.path.expanduser("~"),
+ ".meshchatx_landlock_write_probe",
+ )
+ try:
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write("x")
+ except PermissionError:
+ print("OK")
+ sys.exit(0)
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+ print("WRITE_SHOULD_HAVE_FAILED")
+ sys.exit(3)
+ """,
+ storage=storage,
+ )
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_allows_storage_and_temp_writes(tmp_path):
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ result = run_python_under_landlock(
+ f"""
+ import os
+ import sys
+ import tempfile
+
+ storage = {str(storage)!r}
+ path = os.path.join(storage, "rw-check.txt")
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write("storage")
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ encoding="utf-8",
+ delete=True,
+ ) as tmp:
+ tmp.write("temp")
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
+ )
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_child_interpreter_spawn():
+ """Same pattern as self_check.check_subprocess_spawn."""
+ storage = tempfile.mkdtemp(prefix="meshchat_ll_spawn_")
+ result = run_python_under_landlock(
+ """
+ import subprocess
+ import sys
+
+ child = subprocess.run(
+ [sys.executable, "-c", "print('meshchatx-spawn-ok', flush=True)"],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ check=False,
+ stdin=subprocess.DEVNULL,
+ )
+ if child.returncode != 0:
+ print("SPAWN_RC", child.returncode, child.stderr)
+ sys.exit(4)
+ if "meshchatx-spawn-ok" not in (child.stdout or ""):
+ print("SPAWN_BAD_OUT", child.stdout)
+ sys.exit(5)
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
+ )
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_self_check_subprocess_spawn_helper():
+ storage = tempfile.mkdtemp(prefix="meshchat_ll_selfcheck_")
+ result = run_python_under_landlock(
+ """
+ import sys
+ from meshchatx.src.backend.self_check import check_subprocess_spawn
+
+ out = check_subprocess_spawn()
+ if out.get("status") != "ok":
+ print("SELF_CHECK_FAIL", out.get("reason", ""))
+ sys.exit(3)
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
+ )
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_translator_handler_lists_argos_languages(tmp_path):
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ result = run_python_under_landlock(
+ """
+ import sys
+ from meshchatx.src.backend.translator_handler import TranslatorHandler
+
+ handler = TranslatorHandler(translator_argos_enabled=True)
+ if not handler.has_argos:
+ print("SKIP_NO_ARGOS")
+ sys.exit(0)
+ resp = handler.get_translator_languages_response()
+ argos = [x for x in resp["languages"] if x.get("source") == "argos"]
+ if not argos:
+ print("EMPTY_ARGOS_LANGS has_lib", handler.has_argos_lib, "has_cli", handler.has_argos_cli)
+ sys.exit(4)
+ print("OK", len(argos))
+ sys.exit(0)
+ """,
+ storage=storage,
+ timeout=120,
+ )
+ skip_if_landlock_not_applied(result)
+ if "SKIP_NO_ARGOS" in (result.stdout or ""):
+ pytest.skip("Argos Translate not installed on this host")
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_argospm_list_when_installed(tmp_path):
+ if not shutil.which("argospm"):
+ pytest.skip("argospm not on PATH")
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ result = run_python_under_landlock(
+ """
+ import subprocess
+ import sys
+
+ proc = subprocess.run(
+ ["argospm", "list"],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ check=False,
+ )
+ if proc.returncode != 0:
+ print("ARGOSPM_FAIL", proc.stderr or proc.stdout)
+ sys.exit(3)
+ if not (proc.stdout or "").strip():
+ print("ARGOSPM_EMPTY")
+ sys.exit(4)
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
+ )
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_argos_translate_cli_when_installed(tmp_path):
+ executable = shutil.which("argos-translate")
+ if not executable:
+ pytest.skip("argos-translate not on PATH")
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ result = run_python_under_landlock(
+ f"""
+ import subprocess
+ import sys
+
+ proc = subprocess.run(
+ [{executable!r}, "--from-lang", "en", "--to-lang", "ru", "hello"],
+ capture_output=True,
+ text=True,
+ timeout=120,
+ check=False,
+ )
+ if proc.returncode != 0:
+ print("TRANSLATE_FAIL", proc.stderr or proc.stdout)
+ sys.exit(3)
+ if not (proc.stdout or "").strip():
+ print("TRANSLATE_EMPTY")
+ sys.exit(4)
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
+ timeout=180,
+ )
+ skip_if_landlock_not_applied(result)
+ if result.returncode != 0 and "translate-en_ru" in (
+ result.stdout or result.stderr or ""
+ ):
+ pytest.skip("translate-en_ru package not installed")
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_argos_share_allows_stanza_style_temp_dir(tmp_path):
+ home = os.path.expanduser("~")
+ argos_share = os.path.join(home, ".local", "share", "argos-translate")
+ if not os.path.isdir(argos_share):
+ pytest.skip("Argos data directory not present")
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ result = run_python_under_landlock(
+ f"""
+ import os
+ import sys
+ import tempfile
+
+ base = {argos_share!r}
+ with tempfile.TemporaryDirectory(dir=base) as td:
+ path = os.path.join(td, "probe.txt")
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write("ok")
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
+ )
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_user_local_bin_script_executable(tmp_path):
+ local_bin = os.path.join(os.path.expanduser("~"), ".local", "bin")
+ if not os.path.isdir(local_bin):
+ pytest.skip("~/.local/bin not present")
+ preferred = ("argospm", "argos-translate", "argostranslate", "git-remote-rns")
+ cmd = None
+ for name in preferred:
+ path = os.path.join(local_bin, name)
+ if os.path.isfile(path) and os.access(path, os.X_OK):
+ cmd = path
+ break
+ if cmd is None:
+ pytest.skip("no known pipx-style CLI in ~/.local/bin")
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ result = run_python_under_landlock(
+ f"""
+ import subprocess
+ import sys
+
+ proc = subprocess.run(
+ [{cmd!r}],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ check=False,
+ )
+ if proc.returncode != 0:
+ print("EXEC_FAIL", proc.stderr)
+ sys.exit(3)
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
+ )
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_git_version_when_git_on_path(tmp_path):
+ git = shutil.which("git")
+ if not git:
+ pytest.skip("git not on PATH")
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ result = run_python_under_landlock(
+ f"""
+ import subprocess
+ import sys
+
+ proc = subprocess.run(
+ [{git!r}, "--version"],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ check=False,
+ )
+ if proc.returncode != 0:
+ print("GIT_FAIL", proc.stderr)
+ sys.exit(3)
+ if "git version" not in (proc.stdout or ""):
+ print("GIT_BAD_OUT", proc.stdout)
+ sys.exit(4)
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
+ )
+ assert_probe_ok(result)
+
+
+@requires_landlock_integration
+def test_landlock_rnsh_module_spawn_when_installed(tmp_path):
+ try:
+ import importlib.util
+
+ if importlib.util.find_spec("RNS.Utilities.rnsh.rnsh") is None:
+ pytest.skip("rnsh module not installed")
+ except (ImportError, ValueError, AttributeError):
+ pytest.skip("rnsh module not installed")
+
+ storage = tmp_path / "storage"
+ rns_dir = tmp_path / "rns"
+ rns_dir.mkdir()
+ storage.mkdir()
+ result = run_python_under_landlock(
+ """
+ import subprocess
+ import sys
+
+ proc = subprocess.run(
+ [sys.executable, "-m", "RNS.Utilities.rnsh.rnsh", "--help"],
+ capture_output=True,
+ text=True,
+ timeout=20,
+ check=False,
+ )
+ out = (proc.stdout or "") + (proc.stderr or "")
+ if proc.returncode not in (0, 1, 2) and "rnsh" not in out.lower():
+ print("RNSH_HELP_FAIL", proc.returncode, out[-500:])
+ sys.exit(3)
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
+ reticulum_config_dir=rns_dir,
+ )
+ assert_probe_ok(result)
+
+
+@pytest.mark.parametrize(
+ ("path_name", "collector"),
+ [
+ ("local_bin", lambda home: os.path.join(home, ".local", "bin")),
+ ("pipx", lambda home: os.path.join(home, ".local", "share", "pipx")),
+ (
+ "argos_share",
+ lambda home: os.path.join(home, ".local", "share", "argos-translate"),
+ ),
+ ],
+)
+def test_read_roots_cover_user_local_paths_when_present(path_name, collector):
+ home = os.path.expanduser("~")
+ target = collector(home)
+ if not os.path.isdir(target):
+ pytest.skip(f"{path_name} not present on this host")
+ roots = ll._collect_read_roots()
+ assert any(
+ target == root or target.startswith(root.rstrip("/") + "/") for root in roots
+ ), f"{target!r} not covered by landlock read roots ({path_name})"
diff --git a/tests/backend/test_landlock_sandbox.py b/tests/backend/test_landlock_sandbox.py
index 5e417f91..715968df 100644
--- a/tests/backend/test_landlock_sandbox.py
+++ b/tests/backend/test_landlock_sandbox.py
@@ -97,6 +97,17 @@ def test_collect_read_roots_includes_proc_for_psutil():
assert "/proc" in roots
+def test_collect_read_roots_includes_user_local_cli_when_present():
+ home = os.path.expanduser("~")
+ local_bin = os.path.join(home, ".local", "bin")
+ roots = ll._collect_read_roots()
+ if os.path.isdir(local_bin):
+ assert any(
+ local_bin == root or local_bin.startswith(root.rstrip("/") + "/")
+ for root in roots
+ ), f"{local_bin!r} not covered by landlock read roots"
+
+
def test_collect_read_roots_includes_interpreter_prefix():
roots = ll._collect_read_roots()
exe = os.path.realpath(sys.executable)
@@ -210,24 +221,19 @@ def test_landlock_abi_version_on_linux():
)
def test_apply_landlock_preserves_storage_write_and_truncate(tmp_path):
"""Apply sandbox in a subprocess and confirm RW + truncate still work."""
- import subprocess
- import textwrap
- from pathlib import Path
+ from tests.backend.landlock_integration_support import (
+ assert_probe_ok,
+ run_python_under_landlock,
+ )
storage = tmp_path / "storage"
storage.mkdir()
- script = textwrap.dedent(
+ result = run_python_under_landlock(
f"""
import os
import sys
- from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox
storage = {str(storage)!r}
- os.environ["MESHCHAT_LANDLOCK"] = "1"
- ok = apply_landlock_sandbox(storage_dir=storage, log_dir=storage)
- if not ok:
- print("APPLY_FAILED")
- sys.exit(2)
path = os.path.join(storage, "landlock-abi-check.txt")
with open(path, "w", encoding="utf-8") as handle:
handle.write("hello")
@@ -239,17 +245,52 @@ def test_apply_landlock_preserves_storage_write_and_truncate(tmp_path):
print("TRUNCATE_FAILED", repr(data))
sys.exit(3)
print("OK")
- """
+ sys.exit(0)
+ """,
+ storage=storage,
+ )
+ assert_probe_ok(result)
+
+
+@pytest.mark.skipif(
+ sys.platform != "linux" or not ll.landlock_kernel_supported(),
+ reason="Landlock apply requires a supported Linux kernel",
+)
+def test_apply_landlock_allows_user_local_argospm_list(tmp_path):
+ """Pipx Argos under ~/.local must remain usable for translator language lists."""
+ import shutil
+
+ from tests.backend.landlock_integration_support import (
+ assert_probe_ok,
+ run_python_under_landlock,
)
- result = subprocess.run(
- [sys.executable, "-c", script],
- cwd=str(Path(__file__).resolve().parents[2]),
- capture_output=True,
- text=True,
- timeout=30,
- check=False,
+
+ if not shutil.which("argospm"):
+ pytest.skip("argospm not on PATH")
+
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ result = run_python_under_landlock(
+ """
+ import subprocess
+ import sys
+
+ proc = subprocess.run(
+ ["argospm", "list"],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ check=False,
+ )
+ if proc.returncode != 0:
+ print("ARGOSPM_FAILED", proc.stderr or proc.stdout)
+ sys.exit(3)
+ if not (proc.stdout or "").strip():
+ print("ARGOSPM_EMPTY")
+ sys.exit(4)
+ print("OK")
+ sys.exit(0)
+ """,
+ storage=storage,
)
- if "APPLY_FAILED" in result.stdout:
- pytest.skip("Landlock could not be applied in this environment")
- assert result.returncode == 0, (result.stdout, result.stderr)
- assert "OK" in result.stdout
+ assert_probe_ok(result)
diff --git a/tests/backend/test_lxmf_local_self_message.py b/tests/backend/test_lxmf_local_self_message.py
new file mode 100644
index 00000000..9bcf9ffd
--- /dev/null
+++ b/tests/backend/test_lxmf_local_self_message.py
@@ -0,0 +1,147 @@
+# SPDX-License-Identifier: 0BSD
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import LXMF
+import pytest
+
+LOCAL_LXMF = "a1" * 16
+REMOTE_PEER = "c3" * 16
+
+
+def _fake_lxmf_message():
+ fake_lxm = MagicMock()
+ fake_lxm.fields = {}
+ fake_lxm.hash = MagicMock(hex=lambda: "d4" * 16)
+ fake_lxm.source_hash = MagicMock(hex=lambda: LOCAL_LXMF)
+ fake_lxm.destination_hash = MagicMock(hex=lambda: LOCAL_LXMF)
+ fake_lxm.incoming = False
+ fake_lxm.progress = 0
+ fake_lxm.delivery_attempts = 0
+ fake_lxm.next_delivery_attempt = None
+ fake_lxm.rssi = None
+ fake_lxm.snr = None
+ fake_lxm.q = None
+ fake_lxm.title = b""
+ fake_lxm.timestamp = 1_700_000_000
+ fake_lxm.state = LXMF.LXMessage.OUTBOUND
+ fake_lxm.method = LXMF.LXMessage.DIRECT
+ fake_lxm.get_fields = MagicMock(return_value={})
+ fake_lxm.content = ""
+ fake_lxm.include_ticket = False
+ return fake_lxm
+
+
+def _lxmf_message_factory(*args, **kwargs):
+ msg = _fake_lxmf_message()
+ if len(args) >= 3:
+ raw = args[2]
+ if isinstance(raw, bytes):
+ msg.content = raw
+ else:
+ msg.content = (raw or "").encode("utf-8")
+ return msg
+
+
+@pytest.fixture
+def local_self_app(mock_app):
+ ctx = mock_app.current_context
+ ctx.config.auto_send_failed_messages_to_propagation_node.set(False)
+ ctx.message_router.delivery_link_available.return_value = True
+ ctx.message_router.handle_outbound = MagicMock()
+ ctx.local_lxmf_destination = MagicMock()
+ ctx.local_lxmf_destination.hexhash = LOCAL_LXMF
+ ctx.local_lxmf_destination.hash = bytes.fromhex(LOCAL_LXMF)
+ mock_app.recall_identity = MagicMock(return_value=ctx.identity)
+ mock_app.get_current_icon_hash = MagicMock(return_value=None)
+ mock_app._await_transport_path = AsyncMock()
+ mock_app._convert_webm_opus_to_ogg = MagicMock(side_effect=lambda b: b)
+ mock_app.websocket_broadcast = AsyncMock()
+ if getattr(mock_app, "reticulum", None) is not None:
+ mock_app.reticulum.get_packet_rssi = MagicMock(return_value=None)
+ mock_app.reticulum.get_packet_snr = MagicMock(return_value=None)
+ mock_app.reticulum.get_packet_q = MagicMock(return_value=None)
+ return mock_app
+
+
+def test_is_self_lxmf_destination_by_lxmf_hash(local_self_app):
+ app = local_self_app
+ assert app._is_self_lxmf_destination(LOCAL_LXMF) is True
+ assert app._is_self_lxmf_destination(LOCAL_LXMF.upper()) is True
+ assert app._is_self_lxmf_destination(REMOTE_PEER) is False
+
+
+def test_is_self_lxmf_destination_by_identity_hash(local_self_app):
+ app = local_self_app
+ identity_hex = app.current_context.identity.hash.hex()
+ assert app._is_self_lxmf_destination(identity_hex) is True
+
+
+@pytest.mark.asyncio
+async def test_send_to_self_persists_delivered_local_without_router(local_self_app):
+ app = local_self_app
+ fake_destination = MagicMock()
+ fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
+ app.db_upsert_lxmf_message = MagicMock()
+
+ with (
+ patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
+ patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
+ patch(
+ "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
+ return_value=False,
+ ),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ ):
+ await app.send_message(
+ destination_hash=LOCAL_LXMF,
+ content="note to self",
+ )
+
+ app._await_transport_path.assert_not_called()
+ app.current_context.message_router.handle_outbound.assert_not_called()
+ app.db_upsert_lxmf_message.assert_called_once()
+ upsert_kw = app.db_upsert_lxmf_message.call_args.kwargs
+ assert upsert_kw.get("state_override") == "delivered"
+ assert upsert_kw.get("method_override") == "local"
+ app.websocket_broadcast.assert_awaited()
+ payload = app.websocket_broadcast.await_args[0][0]
+ assert "local" in payload and "delivered" in payload
+
+
+@pytest.mark.asyncio
+async def test_send_to_self_by_identity_hash(local_self_app):
+ app = local_self_app
+ identity_hex = app.current_context.identity.hash.hex()
+ app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=LOCAL_LXMF)
+ fake_destination = MagicMock()
+ fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
+ app.db_upsert_lxmf_message = MagicMock()
+
+ with (
+ patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
+ patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
+ patch(
+ "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
+ return_value=False,
+ ),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ ):
+ await app.send_message(
+ destination_hash=identity_hex,
+ content="via identity hash",
+ )
+
+ app.current_context.message_router.handle_outbound.assert_not_called()
+ assert app.db_upsert_lxmf_message.call_args.kwargs.get("method_override") == "local"
+
+
+@pytest.mark.asyncio
+async def test_send_to_self_rejects_propagated(local_self_app):
+ app = local_self_app
+ with pytest.raises(ValueError, match="yourself"):
+ await app.send_message(
+ destination_hash=LOCAL_LXMF,
+ content="nope",
+ delivery_method="propagated",
+ )
diff --git a/tests/backend/test_lxmf_local_self_oracle.py b/tests/backend/test_lxmf_local_self_oracle.py
new file mode 100644
index 00000000..e95a7149
--- /dev/null
+++ b/tests/backend/test_lxmf_local_self_oracle.py
@@ -0,0 +1,215 @@
+# SPDX-License-Identifier: 0BSD
+"""Oracles and adversarial cases for local (self) LXMF messaging.
+
+Invariant: destination equal to local LXMF, identity, or resolving to local LXMF
+must persist as delivered/local without LXMF router outbound. Any other
+destination must use the mesh path when send succeeds.
+"""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from meshchatx.meshchat import ReticulumMeshChat
+from meshchatx.src.backend import reticulum_pathfinding
+from tests.backend.test_lxmf_local_self_message import (
+ LOCAL_LXMF,
+ REMOTE_PEER,
+ _fake_lxmf_message,
+ _lxmf_message_factory,
+ local_self_app,
+)
+
+
+@pytest.mark.parametrize(
+ ("dest", "expected"),
+ [
+ (LOCAL_LXMF, True),
+ (LOCAL_LXMF.upper(), True),
+ (REMOTE_PEER, False),
+ ("", False),
+ ],
+)
+def test_is_self_lxmf_param_oracle(local_self_app, dest, expected):
+ app = local_self_app
+ assert app._is_self_lxmf_destination(dest) is expected
+
+
+def test_is_self_lxmf_identity_hash_oracle(local_self_app):
+ app = local_self_app
+ identity_hex = app.current_context.identity.hash.hex()
+ assert app._is_self_lxmf_destination(identity_hex) is True
+
+
+@pytest.mark.parametrize(
+ "dest",
+ [
+ REMOTE_PEER,
+ "f0" * 16,
+ "deadbeef" * 4,
+ "0123456789abcdef0123456789abcdef",
+ ],
+)
+def test_is_self_false_for_unrelated_peers(local_self_app, dest):
+ app = local_self_app
+ identity = app.current_context.identity.hash.hex().lower()
+ assert dest.lower() not in {LOCAL_LXMF.lower(), identity}
+ app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=None)
+ assert app._is_self_lxmf_destination(dest) is False
+
+
+@pytest.mark.asyncio
+async def test_local_self_sqlite_row_delivered_local(local_self_app):
+ """End-to-end persist: real db_upsert, not a mock."""
+ app = local_self_app
+ fake_destination = MagicMock()
+ fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
+
+ with (
+ patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
+ patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
+ patch(
+ "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
+ return_value=False,
+ ),
+ patch("meshchatx.meshchat.AsyncUtils.run_async") as run_async,
+ ):
+ await app.send_message(destination_hash=LOCAL_LXMF, content="oracle persist")
+
+ run_async.assert_not_called()
+ row = app.database.provider.fetchone(
+ "SELECT state, method, peer_hash, content FROM lxmf_messages WHERE content = ?",
+ ("oracle persist",),
+ )
+ assert row is not None
+ assert row["state"] == "delivered"
+ assert row["method"] == "local"
+ assert row["peer_hash"] == LOCAL_LXMF
+
+
+@pytest.mark.asyncio
+async def test_local_self_conversation_summary_updated(local_self_app):
+ app = local_self_app
+ fake_destination = MagicMock()
+ fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
+
+ with (
+ patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
+ patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
+ patch(
+ "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
+ return_value=False,
+ ),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ ):
+ await app.send_message(destination_hash=LOCAL_LXMF, content="summary row")
+
+ summary = app.database.provider.fetchone(
+ "SELECT state, content_preview FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (LOCAL_LXMF,),
+ )
+ assert summary is not None
+ assert summary["state"] == "delivered"
+ assert "summary row" in (summary["content_preview"] or "")
+
+
+@pytest.mark.asyncio
+async def test_remote_peer_still_invokes_mesh_outbound(local_self_app):
+ app = local_self_app
+ fake_destination = MagicMock()
+ fake_destination.hash = bytes.fromhex(REMOTE_PEER)
+
+ app._await_transport_path = AsyncMock(
+ return_value=reticulum_pathfinding.OutboundPathOutcome(
+ True,
+ "reused_valid_path",
+ False,
+ ),
+ )
+ app.db_upsert_lxmf_message = MagicMock()
+ progress = AsyncMock()
+ app.handle_lxmf_message_progress = progress
+
+ with (
+ patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
+ patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
+ patch(
+ "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
+ return_value=False,
+ ),
+ patch("meshchatx.meshchat.AsyncUtils.run_async") as run_async,
+ ):
+ await app.send_message(destination_hash=REMOTE_PEER, content="to peer")
+
+ app._await_transport_path.assert_awaited()
+ app.current_context.message_router.handle_outbound.assert_called_once()
+ run_async.assert_called_once()
+ upsert_kw = app.db_upsert_lxmf_message.call_args.kwargs
+ assert upsert_kw.get("method_override") is None
+ assert upsert_kw.get("state_override") is None
+
+
+def test_db_upsert_override_writes_local_method(local_self_app):
+ app = local_self_app
+ msg = _fake_lxmf_message()
+ msg.content = b"stored"
+ ReticulumMeshChat.db_upsert_lxmf_message(
+ app,
+ msg,
+ state_override="delivered",
+ method_override="local",
+ )
+ row = app.database.provider.fetchone(
+ "SELECT state, method FROM lxmf_messages WHERE hash = ?",
+ ("d4" * 16,),
+ )
+ assert row is not None
+ assert row["state"] == "delivered"
+ assert row["method"] == "local"
+
+
+@pytest.mark.asyncio
+async def test_local_self_websocket_payload_is_delivered_local(local_self_app):
+ app = local_self_app
+ fake_destination = MagicMock()
+ fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
+
+ with (
+ patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
+ patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
+ patch(
+ "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
+ return_value=False,
+ ),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ ):
+ await app.send_message(destination_hash=LOCAL_LXMF, content="ws")
+
+ raw = app.websocket_broadcast.await_args[0][0]
+ payload = json.loads(raw)
+ assert payload["type"] == "lxmf_message_created"
+ lm = payload["lxmf_message"]
+ assert lm["state"] == "delivered"
+ assert lm["method"] == "local"
+ assert lm["content"] == "ws"
+
+
+@pytest.mark.asyncio
+async def test_identity_resolved_to_other_lxmf_is_not_self(local_self_app):
+ app = local_self_app
+ other_lxmf = REMOTE_PEER
+ identity_hex = app.current_context.identity.hash.hex()
+ app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=other_lxmf)
+ assert app._is_self_lxmf_destination(identity_hex) is True
+ stranger = "f0" * 16
+ app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=other_lxmf)
+ assert app._is_self_lxmf_destination(stranger) is False
+
+
+def test_oracle_rejects_if_implementation_always_returns_true(local_self_app):
+ """Guard: mutating oracle expectation must fail (documents test strength)."""
+ app = local_self_app
+ assert app._is_self_lxmf_destination(REMOTE_PEER) is False
diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py
index 92b1dc2d..28b82651 100644
--- a/tests/backend/test_translator_handler_extended.py
+++ b/tests/backend/test_translator_handler_extended.py
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshchatx.src.backend.translator_handler import (
+ HAS_ARGOS_LIB,
TranslatorHandler,
_normalize_optional_libretranslate_api_key,
)
@@ -357,3 +358,25 @@ def test_translate_explicit_api_key_overrides_handler_default(mock_session_cls):
)
body = mock_session.post.call_args.kwargs.get("json")
assert body.get("api_key") == "one-off"
+
+
+@pytest.mark.skipif(not HAS_ARGOS_LIB, reason="argostranslate not installed")
+def test_get_supported_languages_falls_back_to_cli_when_lib_has_no_packages():
+ handler = TranslatorHandler(translator_argos_enabled=True)
+ handler.has_argos_lib = True
+ handler.has_argos_cli = True
+ handler.has_argos = True
+ handler.has_requests = False
+ handler.translator_libretranslate_enabled = False
+
+ with (
+ patch("meshchatx.src.backend.translator_handler.package") as mock_pkg,
+ patch.object(handler, "_get_argos_languages_cli") as mock_cli,
+ ):
+ mock_pkg.get_installed_packages.return_value = []
+ mock_cli.return_value = [{"code": "en", "name": "English", "source": "argos"}]
+ langs = handler.get_supported_languages()
+ assert any(
+ lang.get("code") == "en" and lang.get("source") == "argos" for lang in langs
+ )
+ mock_cli.assert_called_once()
diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js
index a65032eb..3c747e5d 100644
--- a/tests/e2e/helpers.js
+++ b/tests/e2e/helpers.js
@@ -3,10 +3,47 @@ const { expect } = require("@playwright/test");
const E2E_BACKEND_PORT = process.env.E2E_BACKEND_PORT || "18079";
const E2E_BACKEND_ORIGIN = `http://127.0.0.1:${E2E_BACKEND_PORT}`;
+const CSRF_HEADER = "X-CSRF-Token";
const E2E_SCROLL_PEER_HASH = `e2e0${"0".repeat(28)}`;
const E2E_SCROLL_ALT_PEER_HASH = `e2e1${"0".repeat(28)}`;
+const _csrfByOrigin = new Map();
+
+/**
+ * @param {import('@playwright/test').APIRequestContext} request
+ * @param {string} origin e.g. http://127.0.0.1:18079
+ */
+async function ensureE2eCsrf(request, origin) {
+ const cached = _csrfByOrigin.get(origin);
+ if (cached) {
+ return cached;
+ }
+ const res = await request.get(`${origin}/api/v1/auth/csrf`);
+ expect(res.ok()).toBeTruthy();
+ const body = await res.json();
+ const token = body.csrf_token;
+ expect(token && typeof token === "string").toBeTruthy();
+ _csrfByOrigin.set(origin, token);
+ return token;
+}
+
+/**
+ * POST with session cookie and CSRF header (Playwright request keeps cookies per context).
+ * @param {import('@playwright/test').APIRequestContext} request
+ * @param {string} url absolute URL
+ * @param {object} [data] JSON body
+ */
+async function e2ePost(request, url, data) {
+ const origin = new URL(url).origin;
+ const token = await ensureE2eCsrf(request, origin);
+ const headers = { [CSRF_HEADER]: token };
+ if (data === undefined) {
+ return request.post(url, { headers });
+ }
+ return request.post(url, { headers, data });
+}
+
function buildE2eLxmfRow({ peerHash, localHash, index, total, inbound }) {
const hash = crypto.randomBytes(16).toString("hex");
const baseTs = Math.floor(Date.now() / 1000) - total;
@@ -70,8 +107,8 @@ async function seedE2eLongConversationThread(request, opts = {}) {
})
);
}
- const imp = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, {
- data: { messages },
+ const imp = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, {
+ messages,
});
expect(imp.ok()).toBeTruthy();
return { peerHash, localHash };
@@ -98,8 +135,8 @@ async function seedE2eAltShortConversationThread(request, opts = {}) {
row.content = `E2E alt short ${String(i).padStart(3, "0")} ${"x".repeat(48)}`;
messages.push(row);
}
- const imp = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, {
- data: { messages },
+ const imp = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, {
+ messages,
});
expect(imp.ok()).toBeTruthy();
return { peerHash, localHash };
@@ -112,10 +149,10 @@ const PALETTE_PLACEHOLDER = /Search commands,\s*(routes|navigate),\s*or peers\.{
* (v-overlay scrim) do not block pointer clicks on the shell.
*/
async function prepareE2eSession(request) {
- const tutorial = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/app/tutorial/seen`);
+ const tutorial = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/app/tutorial/seen`);
expect(tutorial.ok()).toBeTruthy();
- const changelog = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/app/changelog/seen`, {
- data: { version: "999.999.999" },
+ const changelog = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/app/changelog/seen`, {
+ version: "999.999.999",
});
expect(changelog.ok()).toBeTruthy();
}
@@ -157,6 +194,8 @@ module.exports = {
E2E_SCROLL_ALT_PEER_HASH,
PALETTE_PLACEHOLDER,
dismissMapOnboardingTooltip,
+ e2ePost,
+ ensureE2eCsrf,
openCommandPalette,
prepareE2eSession,
getE2eLocalLxmfHash,
diff --git a/tests/e2e/self-message.spec.js b/tests/e2e/self-message.spec.js
new file mode 100644
index 00000000..49025b40
--- /dev/null
+++ b/tests/e2e/self-message.spec.js
@@ -0,0 +1,53 @@
+const { test, expect } = require("@playwright/test");
+const { E2E_BACKEND_ORIGIN, e2ePost, prepareE2eSession } = require("./helpers");
+
+test.describe("E2E CSRF API helpers", () => {
+ test("POST /app/tutorial/seen without CSRF token is rejected", async ({ request }) => {
+ const res = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/app/tutorial/seen`);
+ expect(res.status()).toBe(403);
+ });
+
+ test("e2ePost succeeds for tutorial and changelog seen", async ({ request }) => {
+ await prepareE2eSession(request);
+ });
+});
+
+test.describe("Local self-message API", () => {
+ test.beforeEach(async ({ request }) => {
+ await prepareE2eSession(request);
+ });
+
+ test("POST lxmf-messages/send to own LXMF hash returns delivered local", async ({ request }) => {
+ const cfg = await request.get(`${E2E_BACKEND_ORIGIN}/api/v1/config`);
+ expect(cfg.ok()).toBeTruthy();
+ const localHash = (await cfg.json()).config?.lxmf_address_hash;
+ expect(localHash && String(localHash).length).toBe(32);
+
+ const res = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/lxmf-messages/send`, {
+ lxmf_message: {
+ destination_hash: localHash,
+ content: "E2E personal note",
+ },
+ });
+ expect(res.ok(), await res.text()).toBeTruthy();
+ const body = await res.json();
+ const lm = body.lxmf_message;
+ expect(lm.state).toBe("delivered");
+ expect(lm.method).toBe("local");
+ expect(lm.content).toBe("E2E personal note");
+ });
+
+ test("propagated send to self is rejected", async ({ request }) => {
+ const cfg = await request.get(`${E2E_BACKEND_ORIGIN}/api/v1/config`);
+ const localHash = (await cfg.json()).config?.lxmf_address_hash;
+ const res = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/lxmf-messages/send`, {
+ delivery_method: "propagated",
+ lxmf_message: {
+ destination_hash: localHash,
+ content: "nope",
+ },
+ });
+ expect(res.ok()).toBeFalsy();
+ expect(res.status()).toBeGreaterThanOrEqual(400);
+ });
+});
diff --git a/tests/frontend/conversationViewerSelfPeer.test.js b/tests/frontend/conversationViewerSelfPeer.test.js
new file mode 100644
index 00000000..f915ea1f
--- /dev/null
+++ b/tests/frontend/conversationViewerSelfPeer.test.js
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import ConversationViewer from "@/components/messages/ConversationViewer.vue";
+import ToastUtils from "@/js/ToastUtils";
+
+vi.mock("@/js/ToastUtils", () => ({
+ default: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
+}));
+
+vi.mock("@/js/WebSocketConnection", () => ({
+ default: { on: vi.fn(), off: vi.fn() },
+}));
+
+vi.mock("@/js/GlobalEmitter", () => ({
+ default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
+}));
+
+describe("ConversationViewer self peer detection", () => {
+ const mountViewer = (myLxmf = "aa".repeat(16), identityHash = "bb".repeat(16)) =>
+ mount(ConversationViewer, {
+ props: {
+ selectedPeer: { destination_hash: myLxmf, display_name: "Me" },
+ myLxmfAddressHash: myLxmf,
+ config: { identity_hash: identityHash, lxmf_address_hash: myLxmf },
+ },
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $route: { query: {} },
+ $router: { push: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: true,
+ ConversationPeerHeader: true,
+ ConversationMessageListVirtual: true,
+ SendMessageButton: true,
+ AddImageButton: true,
+ AddAudioButton: true,
+ ContextMenuPanel: true,
+ ContextMenuItem: true,
+ ContextMenuDivider: true,
+ Modal: true,
+ PaperMessageModal: true,
+ ShareContactModal: true,
+ ConversationImageModal: true,
+ LxmfUserIcon: true,
+ AudioWaveformPlayer: true,
+ StickerView: true,
+ InViewAnimatedImg: true,
+ TelemetryHistoryModal: true,
+ ConversationMessageEntry: true,
+ },
+ },
+ });
+
+ it("isSelfLxmfDestination is true for own LXMF hash", async () => {
+ const my = "cc".repeat(16);
+ const wrapper = mountViewer(my, "dd".repeat(16));
+ await flushPromises();
+ expect(wrapper.vm.isSelfLxmfDestination(my)).toBe(true);
+ expect(wrapper.vm.isSelfLxmfDestination(my.toUpperCase())).toBe(true);
+ });
+
+ it("isSelfLxmfDestination is true for own identity hash", async () => {
+ const my = "cc".repeat(16);
+ const ident = "dd".repeat(16);
+ const wrapper = mountViewer(my, ident);
+ await flushPromises();
+ expect(wrapper.vm.isSelfLxmfDestination(ident)).toBe(true);
+ });
+
+ it("isSelfLxmfDestination is false for unrelated peer", async () => {
+ const wrapper = mountViewer();
+ await flushPromises();
+ expect(wrapper.vm.isSelfLxmfDestination("ee".repeat(16))).toBe(false);
+ expect(wrapper.vm.isSelfLxmfDestination("")).toBe(false);
+ });
+});
diff --git a/tests/frontend/messagesPageOutboundCompose.oracle.test.js b/tests/frontend/messagesPageOutboundCompose.oracle.test.js
new file mode 100644
index 00000000..ff726ea3
--- /dev/null
+++ b/tests/frontend/messagesPageOutboundCompose.oracle.test.js
@@ -0,0 +1,120 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+/**
+ * Oracle: optimistic sidebar bump on compose enqueue must appear before WS ack.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import MessagesPage from "@/components/messages/MessagesPage.vue";
+
+vi.mock("@/js/GlobalEmitter", () => ({
+ default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
+}));
+
+vi.mock("@/js/NotificationUtils", () => ({
+ default: {
+ clearMessageNotifications: vi.fn(),
+ clearAllMessageNotifications: vi.fn(),
+ showNewMessageNotification: vi.fn(),
+ syncAndroidNotificationContext: vi.fn(),
+ },
+}));
+
+const PEER = "bb".repeat(16);
+
+describe("MessagesPage outbound compose oracle", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn().mockImplementation((url) => {
+ if (url === "/api/v1/config") {
+ return Promise.resolve({
+ data: { config: { lxmf_address_hash: "aa".repeat(16) } },
+ });
+ }
+ if (url === "/api/v1/lxmf/conversations") {
+ return Promise.resolve({ data: { conversations: [] } });
+ }
+ if (url === "/api/v1/announces") {
+ return Promise.resolve({ data: { announces: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.api = axiosMock;
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ const mountPage = () =>
+ mount(MessagesPage, {
+ props: { destinationHash: "" },
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $route: { query: {} },
+ $router: { replace: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: true,
+ LoadingSpinner: true,
+ MessagesSidebar: true,
+ ConversationViewer: true,
+ Modal: true,
+ },
+ },
+ });
+
+ it("onOutboundComposeEnqueued inserts conversation row with preview before server ack", async () => {
+ const wrapper = mountPage();
+ await flushPromises();
+ expect(wrapper.vm.conversations).toHaveLength(0);
+
+ wrapper.vm.onOutboundComposeEnqueued({
+ peerHash: PEER,
+ previewText: "hello optimistic",
+ title: "",
+ fields: {},
+ });
+
+ expect(wrapper.vm.conversations).toHaveLength(1);
+ expect(wrapper.vm.conversations[0].destination_hash).toBe(PEER);
+ expect(wrapper.vm.conversations[0].latest_message_preview).toContain("hello optimistic");
+ expect(wrapper.vm.conversations[0].latest_message_created_at).toBeGreaterThan(0);
+ });
+
+ it("onOutboundComposeEnqueued updates existing row instead of duplicating", async () => {
+ const wrapper = mountPage();
+ await flushPromises();
+ wrapper.vm.conversations = [
+ {
+ destination_hash: PEER,
+ display_name: "Peer",
+ latest_message_preview: "old",
+ latest_message_created_at: 1,
+ updated_at: new Date(1000).toISOString(),
+ },
+ ];
+
+ wrapper.vm.onOutboundComposeEnqueued({
+ peerHash: PEER,
+ previewText: "new text",
+ title: "t",
+ fields: {},
+ });
+
+ expect(wrapper.vm.conversations).toHaveLength(1);
+ expect(wrapper.vm.conversations[0].latest_message_preview).toContain("new text");
+ expect(wrapper.vm.conversations[0].latest_message_title).toBe("t");
+ expect(wrapper.vm.conversations[0].latest_message_created_at).toBeGreaterThan(1);
+ });
+
+ it("onOutboundComposeEnqueued ignores empty peerHash (must not create orphan row)", () => {
+ const wrapper = mountPage();
+ wrapper.vm.onOutboundComposeEnqueued({ peerHash: "", previewText: "x" });
+ expect(wrapper.vm.conversations).toHaveLength(0);
+ });
+});
diff --git a/tests/frontend/outboundMessageStatus.oracle.test.js b/tests/frontend/outboundMessageStatus.oracle.test.js
index eb68f922..0b35a866 100644
--- a/tests/frontend/outboundMessageStatus.oracle.test.js
+++ b/tests/frontend/outboundMessageStatus.oracle.test.js
@@ -26,7 +26,7 @@ const API_STATES = [
"unknown",
];
-const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "unknown"];
+const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "local", "unknown"];
/**
* Independent UI oracle. Must stay in sync with product intent, not by
@@ -35,12 +35,12 @@ const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "unknown"
function oracleIconName({ state, method }) {
if (state === "delivered") {
if (method === "propagated") return "email-check-outline";
- if (method === "paper") return "note-check-outline";
+ if (method === "paper" || method === "local") return "note-check-outline";
return "check-all";
}
if (state === "sent" || state === "propagated" || state === "unknown") {
if (method === "propagated") return "email-outline";
- if (method === "paper") return "note-outline";
+ if (method === "paper" || method === "local") return "note-outline";
return "check";
}
return "check";
@@ -49,8 +49,10 @@ function oracleIconName({ state, method }) {
function oracleTitleKey({ state, method }) {
if (state === "delivered") {
if (method === "propagated") return "messages.outbound_delivered_propagated";
+ if (method === "local") return "messages.outbound_delivered_local";
return "messages.outbound_delivered";
}
+ if (method === "local") return "messages.outbound_saved_local";
if (method === "propagated") return "messages.outbound_on_propagation_node";
return "messages.outbound_sent_network";
}
@@ -113,6 +115,17 @@ describe("outboundMessageStatus LXMF oracle", () => {
expect(outboundBubbleStatusIconName({ state: "delivered", method: "paper" })).toBe("note-check-outline");
});
+ it("local method uses note-check icon when delivered", () => {
+ const msg = { state: "delivered", method: "local" };
+ expect(outboundBubbleStatusIconName(msg)).toBe("note-check-outline");
+ expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_delivered_local");
+ });
+
+ it("local method while sending uses note outline title for saved local", () => {
+ const msg = { state: "sending", method: "local" };
+ expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_saved_local");
+ });
+
it("backend never emits state=propagated but UI still treats it as sent-like", () => {
expect(outboundBubbleStatusIconName({ state: "propagated", method: "direct" })).toBe("check");
expect(outboundBubbleStatusIconName({ state: "propagated", method: "propagated" })).toBe("email-outline");
diff --git a/tests/test_android_emulator_smoke_script.py b/tests/test_android_emulator_smoke_script.py
index a203a480..cab3688d 100644
--- a/tests/test_android_emulator_smoke_script.py
+++ b/tests/test_android_emulator_smoke_script.py
@@ -46,3 +46,4 @@ def test_workflow_references_smoke_script():
smoke_script = smoke_step.split("script:", 1)[1].split("\n", 1)[0]
assert "bash scripts/ci/android-emulator-smoke.sh" in smoke_script
assert "set -euo pipefail" not in smoke_script
+ assert "adb forward" in _SCRIPT.read_text(encoding="utf-8")
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────